Repository navigation
feat(service-print): the print door — POST /api/v1/print/records prints a record as the caller; the showcase invoice declares Generate PDF - #24194
Conversation
… (wip) Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…wcase invoice's Generate PDF action (wip) Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…fault log Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…e print plugin from source Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…til a request arrives Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 39 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 418ad3608969d7670826dadd8e2155d172af2358 && git checkout 418ad3608969d7670826dadd8e2155d172af2358
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d6ec91d2f89e834fc5b82183a95cb3c5427a882 8e8cfc60af7cf52b7b535b4e6ccafd1e37e84597 && git checkout -B drift-repro 7d6ec91d2f89e834fc5b82183a95cb3c5427a882 && git merge --no-ff 8e8cfc60af7cf52b7b535b4e6ccafd1e37e84597
node scripts/docs-audit/affected-docs.mjs --json 7d6ec91d2f89e834fc5b82183a95cb3c5427a882
|
…ansport Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
… real auth service Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
席位核对ACCEPT ·
Generated by Claude Code |
… beside Print (objectstack-ai#12921) Fixes objectstack-ai#12906 Clause-②: yes (measured correction of the claim's `no`: `@object-ui/components` gains a new export, `PageHeaderActions` and its props type `PageHeaderActionsProps`. No spec key, registry key or printable component type changes.) ## What changed - **`@object-ui/components`, `containers.tsx`.** `page:header`'s action row (declared-id resolution, the location / capability / `visible` / `hidden` gates, the merge with `RecordContext.headerSystemActions`, ordering, the inline / `⋯` split, localization and `dispatchHeaderAction`) is lifted, unchanged, out of `PageHeaderRenderer` into an exported `PageHeaderActions` component. `PageHeaderRenderer` now draws its tail through it, passing its own `actions` / `maxVisible` / `mobileMaxVisible` and the empty slot. One resolver, two callers; ⛔ no second resolver. - **`@object-ui/app-shell`, `RecordDetailView.tsx`.** On a page that declares `print`, the view draws a screen-only chrome row (`data-record-print-chrome`) at the top of the content column, inside the record's `RecordContextProvider` / `ActionProvider`: the shared chrome (presence, managed-by badge), `PageHeaderActions` fed the object's declared `record_header` / `record_more` actions (`synthParts.headerActions`; the system Edit / Share / Delete come from the context as on every record page), and the Print button. The row is outside the print page root, so the print stylesheet's existing hide rule (everything that neither is the print page, holds it, nor sits in it) hides it on paper exactly as it hid Print. The Print button moved from the absolute top-right overlay into this row so the new buttons are not drawn over the page's own top-right content. - A record page without `print` is unchanged: the absolute overlay keeps presence and the badge, and `page:header` draws the actions. ## Mechanism readings (dispatch A1 to A3) - **A1.** The resolver is the action pipeline inside `PageHeaderRenderer` (`containers.tsx`), now the exported `PageHeaderActions`; the system set reaches it through `RecordContext.headerSystemActions` (`synthSystemActions` in `RecordDetailView`). The print chrome reaches it by rendering `PageHeaderActions` inside the same providers. - **A2.** Pins in `RecordDetailView.printPageHeaderActions-12906.test.tsx`: (a) Submit for Sign-off, Edit and the `⋯` (favorites / Share / Delete) in the chrome beside Print, Edit calling the host's `onEdit`; (b) Print, the toolbar and the chrome are neither inside nor around `[data-print-page]`, the page's stylesheet carries the hide rule verbatim, and toolbar and Print share one chrome container; (c) clicking the declared `type: 'api'` action sends `POST /api/v1/data/OBJECT/INV-1/submit` through the record page's api handler, and the request equals the one the same click sends on the default (non-print) record page; (d) CONTROL: without `print`, no chrome, no Print, one toolbar inside the `header`, each action drawn once. `page-header-actions-shared-12906.test.tsx` pins that `PageHeaderActions` draws byte-identical toolbar markup (React ids blanked) to `page:header` for the same context and keys, the system set alone, and the empty slot. - **Ablation** (`ablation-replace.mjs`, anchor the `PageHeaderActions` line in the print chrome, deleted): anchor 1 to 0, blob `c6680e738e59` to `a51ab974dbe1`; (a), (b), (c) red, (d) green; restore verified blob equals HEAD `c6680e738e59` and `git diff HEAD` empty. No dist in the path: the test resolves both packages from source through the vitest alias. - **A3.** NOT MEASURED: no browser print preview was taken (no backend booted for this round). - **"Generate PDF"** (objectstack-ai/objectstack#22270, PR objectstack-ai/objectstack#24194): pending, not exercised. It is a declared `record_header` action, so it rides the same route as Submit for Sign-off once it lands in the example. ## Verification at 5741ebd - Closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' run build`: exit 0. - `pnpm --filter @object-ui/components run type-check` and `pnpm --filter @object-ui/app-shell run type-check`: exit 0, both echoed `tsc --noEmit && tsc -p tsconfig.test.json`. - vitest `packages/components/`: 404 files passed, 1 skipped; 4047 tests passed. vitest `packages/app-shell/`: 1555 files passed, 1 skipped; 14635 tests passed. - eslint narrowed to the 5 changed source and test files (`--no-inline-config --format json`): 5 files, 0 errors. Population: the per-package `eslint .` the lint workflow runs; type-aware linting is not enabled in `eslint.config.js` (no `parserOptions.project` / `projectService`), so this diff cannot move a verdict on an untouched file. - Pre-tsc battery, each exit 0: `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:doc-example-readers`, `check:handler-key-reads`, `check:side-effects-array`, `check:element-data-source-declaration`, `check:spec-symbols`, `check:action-forward-parity`, `check:designer-field-key-parity`, `check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`, `check:control-bytes`, `check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs`, `check-changeset-presence.mjs`, `check:new-line-citations` (0 new). ## Acceptance notes - File surface: `packages/components/src/index.ts` gains the one export line pair. It is outside the dispatched surface, and it is the only way `app-shell` can reach the shared resolver (the package exports only its root barrel). Declared here and in the report. - Docs (`content/docs/guide`, package README) are not updated in this round; the surface did not include them. - objectui#11958 is the print page this builds on; it is referenced, not addressed. The session that wrote this: `https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB`. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22270
Clause-②: no
Print page ④ of #8346 (ruling of record
6051470224): the door a record-page "generate PDF" action calls, and one declared action on the showcase invoice. The PDF is attached by card ③'s service (#22269, landed asb15f8a1997), once, as the caller; this PR adds no second attach.Dispatched by the
domain:servicesseat 1 (seat post #6021), claim6234883974, sessionsession_01Nk17Z26X5DB59iQztDBADH. The grade is the maintainer's 「PDF 要赶 18.0 正式版」.What it adds
POST /api/v1/print/records(packages/services/service-print/src/print-routes.ts), mounted byPrintServicePluginon the kernel's HTTP server atkernel:ready, on every placement path. The JSON body is the in-process request,{ app, object, recordId, page, filename? }. The door resolves the caller with the platform's one resolver (resolveAuthzContext), assembles the execution context as the REST face does (assembleExecutionContext), and hands that context and the request toIPrintService.printRecordToFile. It answers201with{ fileId, attachmentId, size, filename }. It holds no attachments handle, so it cannot attach.PrintServicePlugin, andshowcase_invoicedeclaresshowcase_generate_invoice_pdf: atype: 'api'action,target: '/api/v1/print/records',recordIdParam: 'recordId',bodyExtra: { app, object, page }naming the invoice's default record pageshowcase_invoice_document(the page that declaresprint). The zh-CN label and success message are translated at birth (the example's i18n ratchet).print-route-ledger.ts(oneserver-onlyrow) with its package-local conformance test, read by the dogfood live-mount parity gate beside the other ledgers.The door's order, and what it relays
Door refusals, each before the service is asked, so nothing is minted and no browser is started:
SERVICE_UNAVAILABLE/ 503; an API key its lifecycle refuses relays its own 403 and code.UNAUTHENTICATED/ 401 (the shared anonymous floor).evaluateAuthGate, as the other doors).PERMISSION_DENIED/ 403, with a message saying the print was refused because the session is an impersonation. No new error code.VALIDATION_ERROR/ 400. A misspelled field is refused, never ignored.Then every refusal card ③ declares keeps its
codeandstatusunchanged:PERMISSION_DENIED/ 403,SERVICE_UNAVAILABLE/ 503 (no render driver configured),RESOURCE_NOT_FOUND/ 404,VALIDATION_ERROR/ 400 (a page withoutprint),RECORD_NOT_FOUND/ 404 (a caller who cannot read the record),FILES_DISABLED/ 403,ATTACHMENT_PARENT_ACCESS/ 403,TIMEOUT/ 504,INTERNAL_ERROR/ 500,PAYLOAD_TOO_LARGE/ 413. A 4xx keeps the service's message. A 5xx answers with the door's own sentence for its status and the service's text goes to the server fault log (declaredRefusalMessage/sendServerFault, the shared rule every door applies to a declared 5xx, because a 5xx text can carry what the engine said about the host). An undeclared throw isINTERNAL_ERROR/ 500.Door decisions (the seat's ruling, applied)
packages/specchange: the session the auth service reads for the request carriesimpersonatedBy(better-auth's admin plugin field, which@objectstack/plugin-authstores assys_session.impersonated_by). Measured statically (the admin plugin's session schema returns the field;plugin-auth'scustomSessionpassessessionthrough unchanged) and on a real showcase boot with a local-only scratch test that was deleted afterwards: an impersonation session's in-process session read carried the impersonating administrator's id, and a user's own session carriednull. The door reads the mark off the same session read that admitted the caller, and only for a session credential (a key-admitted request is judged by the key alone). Described abstractly here on purpose.sys_file.owner_id,sys_attachment.uploaded_by) attribute the print to the caller.IAuthServiceprovider that marks impersonation some other way is not recognised. Its reach is what card ③ already ships.Why this path
/api/v1/print/records, under the versioned API prefix as/api/v1/storageis, plural per the REST naming rule, and mirroringprintRecordToFile. A body-addressed route rather than/:object/:recordIdbecause both consoleapihandlers at the pin (useConsoleActionRuntime.apiHandler,RecordDetailView.apiHandler) seedrecordIdParaminto the body and mergebodyExtra, while{field}path interpolation exists on one of them only. The route is plugin-mounted (the storage and settings pattern), so one owner serves it, and only where print is composed.Measured: the console at the pin (
.objectui-sha5e15928b)type: 'api'action with an absolute target is POSTed as JSON (credentials: 'include', bearer added for a same-origin target): the collected params, thenrecordIdParamseeded from the row or the page record, thenbodyExtra. A non-2xx answer becomes the action's error witherror.messagefrom the envelope, so each refusal above reaches the user as a message.page:headeronly, and a print page may not hold one (PRINTABLE_PAGE_COMPONENT_TYPESrefusespage:header). The invoice's default record page is a print page sinceb15f8a1997, so itsrecord_headeractions — this PR's Generate PDF, and the existing Submit for Sign-off — are not drawn there, and neither are the system Edit / Share / Delete actions. The action is therefore also placed atlist_item(the invoice list's row menu), where the console draws it today. The record-page host is a console change: reported to the seat for objectui, not made here.Landing sites outside the claim's file surface, and why
packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts(2 lines): the showcase now mounts the door, and that gate's "every live mount is ledgered" direction reds on an unledgered mount. The print ledger joins its union.scripts/check-route-envelope.mjs(oneMODULESrow): a*-routes.tsmodule must be declared there; declared at0 / 0 / 0(every body through the shared pair).content/docs/ui/print-pages.mdx: the door and its action, documented.pnpm-lock.yaml(6 lines): two workspace links,@objectstack/typesforservice-printand@objectstack/service-printfor the showcase.packages/services/service-print/package.jsonand itsvitest.config.tsalias are inside the package, outsidesrc/**.Clause-② measured
dist/index.d.tsof the built@objectstack/service-printexports the same 14 names as before (src/index.tsis byte-identical to the base). The class declaration gains oneprivate mountDoor;member. No new plugin option, no schema change, no new error code.Tests (head
021f86051)pnpm --filter @objectstack/service-print test: 8 files, 109 tests passed.typecheckexit 0 (both new test files are in the tsc program, counted with--listFiles).print-routes.test.ts(25 tests) drives the mounted handler with the real resolver and the realPrintServiceover recording doubles: the success path stores exactly onesys_attachmentrow for the print (and none through the system writer), the context the door built reaches the mint by identity, and every refusal is asserted bycode+statuson an envelope that parses asApiError.examples/app-showcase: 36 files, 431 tests passed, typecheck exit 0,objectstack validatepassed (at69f5ac29e; the last commit changed one comment inprint-routes.ts). The newinvoice-generate-pdf-action.test.ts(4 tests, re-run at021f86051) sends the body the console builds from the declaration to the door the showcase's own plugin mounts: with no engine configured it is answeredSERVICE_UNAVAILABLE/ 503.69f5ac29e):route-ledger-live-mount-parity,print-service-archive,anonymous-door-sweep: 3 files, 37 tests passed (the door is reachably mounted and ledgered, and answers an anonymous probe 401).Ablation, each through
scripts/ablation-replace.mjs(anchor hit 1 → 0, blob changed), each red, each restored with the blob equal toHEADandgit diff HEADempty: the impersonation guard (an impersonated print answered success), the anonymous floor (PERMISSION_DENIED/ 403 instead of 401), the body's closed set, the query refusal, and card ③'s no-engine (INTERNAL_ERROR/ 500 instead of 503), no-printand read-as-caller guards (each print then answered success).Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat021f86051: 128 commands, all run locally on that head, all exit 0 (each exit captured before any pipe).--ranreconciliation: 128 derived, 128 run, 0 NOT-MEASURED, 0 UNRUN, every family carrying its exit code. The earlier run on86e1da243was red in two real places, both fixed here:check:i18n-coverage(the showcase's untranslated count grew 414 → 416, the new action's label and message; now translated) andcheck:test-source-alias(the showcase's new import of@objectstack/service-print; now aliased to source). Two more answered PREREQUISITE NOT MET (exit 3) until the workspace was built, then 0. CI runs the full set on this PR.Patch round 1 (head
8e8cfc60a)print-routes.tscallsresolveAuthzContext, so it is now aguardedrow ofTRANSPORT_LEDGERinpackages/core/src/security/authz-store-unavailable.test.ts(its caller-resolutioncatchrelays the outage with its own status and code).@objectstack/core#test:repowas red without the row (10 transports against 9 rows) and is green with it. That file is the engine lane's; one row, declared by the seat.packages/qa/dogfood/test/print-door-impersonation.dogfood.test.ts, on a real showcase boot with no print engine: an administrator's own session is not refused for impersonation (the service answersSERVICE_UNAVAILABLE/ 503), and an impersonation session that administrator starts is refused at the door (PERMISSION_DENIED/ 403, the message naming impersonation). The session mark comes from the composed auth service, so a change that drops or renames it reds this pin instead of turning the refusal off. Ablated once through the built package: with the guard disabled the pin answered 503 instead of 403; restored withgit diff HEADempty and the marker gone fromdist/.main; on this head the touched suites (service-print 109, showcase 431, core repo 85, dogfood 4 files / 38) and the re-derived 130 gates are green locally, and CI is green.Acceptance notes
OS_PRINT_*set, a showcase boot now logs onewarnthat printing is off, naming the settings (the plugin's existing "absence is loud" line).route-ledger-live-mount-parity.dogfood.test.ts's header still says "Five route ledgers"; the union it reads was already wider (settings) before this PR.021f86051the branch was behindmainby three commits that touch none of its files. Patch round 1 mergedmain(merge base7d6ec91d2).printshows none of its header actions — no Edit / Share / Delete, no declared record_header action (the showcase invoice hides "Submit for Sign-off" and "Generate PDF") objectui#12906 (p1). On a record whose default record page declaresprint, the console draws none of the header actions, so the record page shows no "Generate PDF". The showcase action is also placed atlist_itemmeanwhile.session.impersonatedBy, is not a field the session contract declares. That is filed as spec(contracts):AuthSessionApi.getSessionclaims to declare every field its readers read, but its result declares three; readers cast to readsession.id,token,user.email, the auth gate andimpersonatedBy#24196 for the spec lane. Until it lands, the dogfood pin above is the guard against the mark going away.Generated by Claude Code